/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package org.unidata.mdm.meta.service.impl.sourcesystems;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import org.unidata.mdm.core.context.ModelChangeContext;
import org.unidata.mdm.core.context.ModelGetContext;
import org.unidata.mdm.core.context.ModelRefreshContext;
import org.unidata.mdm.core.context.ModelRemoveContext;
import org.unidata.mdm.core.dto.ModelGetResult;
import org.unidata.mdm.core.service.CustomPropertiesSupport;
import org.unidata.mdm.core.service.MetaModelService;
import org.unidata.mdm.core.service.impl.AbstractModelComponent;
import org.unidata.mdm.core.type.model.ModelDescriptor;
import org.unidata.mdm.core.type.model.ModelImplementation;
import org.unidata.mdm.core.type.model.ModelSource;
import org.unidata.mdm.core.util.SecurityUtils;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.meta.configuration.TypeIds;
import org.unidata.mdm.meta.context.GetSourceSystemsContext;
import org.unidata.mdm.meta.context.SourceSourceSystemsContext;
import org.unidata.mdm.meta.context.UpsertSourceSystemsContext;
import org.unidata.mdm.meta.dao.SourceSystemsDAO;
import org.unidata.mdm.meta.dto.GetSourceSystemResult;
import org.unidata.mdm.meta.dto.GetSourceSystemsResult;
import org.unidata.mdm.meta.exception.MetaExceptionIds;
import org.unidata.mdm.meta.exception.ModelRuntimeException;
import org.unidata.mdm.meta.exception.ModelValidationException;
import org.unidata.mdm.meta.po.SourceSystemsPO;
import org.unidata.mdm.meta.serialization.MetaSerializer;
import org.unidata.mdm.meta.service.impl.sourcesystems.instance.SourceSystemsInstanceImpl;
import org.unidata.mdm.meta.service.impl.sourcesystems.instance.SourceSystemsInstanceImpl.SourceSystemImpl;
import org.unidata.mdm.meta.type.instance.SourceSystemsInstance;
import org.unidata.mdm.meta.type.model.sourcesystem.SourceSystem;
import org.unidata.mdm.meta.type.model.sourcesystem.SourceSystemsModel;
import org.unidata.mdm.system.exception.ValidationResult;

/**
 * @author Mikhail Mikhailov on Oct 8, 2020
 */
@Component(TypeIds.SOURCE_SYSTEMS_MODEL)
public class SourceSystemsComponent extends AbstractModelComponent implements ModelImplementation<SourceSystemsInstance>, CustomPropertiesSupport {
    /**
     * Invalid weight value supplied.
     */
    private static final String INCORRECT_WEIGHT_FOR_SOURCE_SYSTEM = "app.meta.ss.weight";
    /**
     * The Constant SOURCE_SYSTEM_NAME_TOO_LONG.
     */
    private static final String SOURCE_SYSTEM_NAME_TOO_LONG = "app.meta.ss.name.too.long";
    /**
     * The Constant SOURCE_SYSTEM_NAME_EMPTY.
     */
    private static final String SOURCE_SYSTEM_NAME_EMPTY = "app.meta.ss.name.empty";
    /**
     * The Constant INCORRECT_ADMIN_SOURCE_SYSTEM.
     */
    private static final String INCORRECT_ADMIN_SOURCE_SYSTEM = "app.meta.ss.admin.incorrect";
    /**
     * SS DAO.
     */
    @Autowired
    private SourceSystemsDAO sourceSystemsDAO;
    /**
     * The MMS.
     */
    private MetaModelService metaModelService;
    /**
     * SSI. Source systems are singleton chache per storage id.
     */
    private final Map<String, SourceSystemsInstance> instances = new ConcurrentHashMap<>(4);
    /**
     * Constructor.
     */
    @Autowired
    public SourceSystemsComponent(MetaModelService metaModelService) {
        super();
        this.metaModelService = metaModelService;
        metaModelService.register(this);
    }

    @Override
    public ModelDescriptor<SourceSystemsInstance> descriptor() {
        return Descriptors.SOURCE_SYSTEMS;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public SourceSystemsInstance instance(String storageId, String id) {
        return instances.computeIfAbsent(storageId, this::load);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public ModelGetResult get(ModelGetContext get) {

        GetSourceSystemsContext ctx = narrow(get);
        GetSourceSystemsResult result = new GetSourceSystemsResult();

        SourceSystemsInstance i = instance(SecurityUtils.getStorageId(ctx), null);
        processSourceSystems(i, ctx, result);
        processInfoFields(i, ctx, result);

        return result;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public SourceSystemsModel assemble(ModelChangeContext ctx) {

        UpsertSourceSystemsContext change = narrow(ctx);

        SourceSystemsInstance current = instance(SecurityUtils.getStorageId(change), null);
        SourceSystemsModel target = new SourceSystemsModel();
        if (change.getUpsertType() == ModelChangeContext.ModelChangeType.FULL) {
            target.withValues(change.getSourceSystemsUpdate());
        } else {

            Map<String, SourceSystem> merge = current.toSource()
                    .getValues()
                    .stream()
                    .collect(Collectors.toMap(SourceSystem::getName, Function.identity()));

            if (CollectionUtils.isNotEmpty(change.getSourceSystemsUpdate())) {
                merge.putAll(
                    change.getSourceSystemsUpdate().stream()
                        .collect(Collectors.toMap(SourceSystem::getName, Function.identity())));
            }

            if (CollectionUtils.isNotEmpty(change.getSourceSystemsDelete())) {
                change.getSourceSystemsDelete().forEach(merge::remove);
            }

            target.withValues(merge.values());
        }

        target
            .withVersion(current.getVersion() + 1)
            .withCreateDate(OffsetDateTime.now())
            .withCreatedBy(SecurityUtils.getCurrentUserName());

        processInfoFields(target, change);

        return target;
    }
    /**
     * {@inheritDoc}
     */
    public void put(String storageId, ModelSource src) {

        Objects.requireNonNull(storageId, "Storage id must not be null.");
        SourceSystemsModel source = narrow(src);

        SourceSystemsPO po = new SourceSystemsPO();
        po.setCreatedBy(SecurityUtils.getCurrentUserName());
        po.setStorageId(storageId);
        po.setRevision(source.getVersion());
        po.setContent(MetaSerializer.sourceSystemsToCompressedXml(source));

        try {
            sourceSystemsDAO.save(po);
        } catch (DuplicateKeyException dke) {
            throw new ModelRuntimeException(
                    "Cannot save source systems. Revisions conflict [expected next {}].",
                    dke,
                    MetaExceptionIds.EX_META_UPSERT_SOURCE_SYSTEMS_REVISION_EXISTS,
                    po.getRevision());
        }
    }

    @Override
    public Collection<ValidationResult> validate(ModelSource src) {

        SourceSystemsModel source = narrow(src);

        List<ValidationResult> errors = new ArrayList<>();
        Set<String> adms = new HashSet<>();
        for (SourceSystem ss : source.getValues()) {

            errors.addAll(checkValue(ss));

            if (ss.isAdmin()) {
                adms.add(ss.getName());
                // if source system type changed from admin to ordinary source system remove it
                // from the set
            }
        }

        if (adms.size() != 1) {
            String message = "Admin source system must be defined exactly once found [{}] admin source system. Names: [{}]";
            errors.add(new ValidationResult(message, INCORRECT_ADMIN_SOURCE_SYSTEM, adms.size(), adms.toString()));
        }

        return errors;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void upsert(ModelChangeContext ctx) {

        UpsertSourceSystemsContext change = narrow(ctx);
        SourceSystemsModel target = assemble(change);

        List<ValidationResult> validations = new ArrayList<>();
        validations.addAll(validate(target));
        validations.addAll(metaModelService.allow(SourceSourceSystemsContext.builder()
                    .source(target)
                    .build()));

        if (CollectionUtils.isNotEmpty(validations)) {
            throw new ModelValidationException("Cannot upsert source systems. Validation errors exist.",
                    MetaExceptionIds.EX_META_UPSERT_SOURCE_SYSTEMS_VALIDATION, validations);
        }

        put(SecurityUtils.getStorageId(change), target);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void remove(ModelRemoveContext remove) {
        // Not supported
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void refresh(ModelRefreshContext refresh) {
        instances.computeIfPresent(SecurityUtils.getStorageId(refresh), (k, v) -> load(k));
    }

    private UpsertSourceSystemsContext narrow(ModelChangeContext ctx) {

        Objects.requireNonNull(ctx, "Source systems change input must not be null.");
        if (!StringUtils.equals(ctx.getTypeId(), TypeIds.SOURCE_SYSTEMS_MODEL)) {
            throw new ModelRuntimeException("Wrong input type [{}] for source systems change operation, [{}] expected.",
                    MetaExceptionIds.EX_META_SOURCE_SYSTEMS_CHANGE_WRONG_TYPE,
                    ctx.getTypeId(), TypeIds.SOURCE_SYSTEMS_MODEL);
        }

        return (UpsertSourceSystemsContext) ctx;
    }

    private GetSourceSystemsContext narrow(ModelGetContext ctx) {

        Objects.requireNonNull(ctx, "Source systems get input must not be null.");
        if (!StringUtils.equals(ctx.getTypeId(), TypeIds.SOURCE_SYSTEMS_MODEL)) {
            throw new ModelRuntimeException("Wrong input type [{}] for source systems get operation, [{}] expected.",
                    MetaExceptionIds.EX_META_SOURCE_SYSTEMS_GET_WRONG_TYPE,
                    ctx.getTypeId(), TypeIds.SOURCE_SYSTEMS_MODEL);
        }

        return (GetSourceSystemsContext) ctx;
    }

    private SourceSystemsModel narrow(ModelSource src) {

        Objects.requireNonNull(src, "Source systems source input must not be null.");
        if (!StringUtils.equals(src.getTypeId(), TypeIds.SOURCE_SYSTEMS_MODEL)) {
            throw new ModelRuntimeException("Wrong input type [{}] for source systems source operation, [{}] expected.",
                    MetaExceptionIds.EX_META_SOURCE_SYSTEMS_SOURCE_WRONG_TYPE,
                    src.getTypeId(), TypeIds.SOURCE_SYSTEMS_MODEL);
        }

        return (SourceSystemsModel) src;
    }

    private List<ValidationResult> checkValue(SourceSystem ss) {

        List<ValidationResult> errors = new ArrayList<>();

        // 1. Name
        if (StringUtils.isBlank(ss.getName())) {
            String message = "Source system's name blank. Source system name must not be blank.";
            errors.add(new ValidationResult(message, SOURCE_SYSTEM_NAME_EMPTY));
        } else {

            // 2. Name length
            if(ss.getName().length() > 255) {
                String message = "Source system name too long. Source system [{}] max length [{}]";
                errors.add(new ValidationResult(message, SOURCE_SYSTEM_NAME_TOO_LONG, ss.getName(), 255));
            }
        }

        // 3. Weight
        if (ss.getWeight() == null || ss.getWeight().intValue() > 100 || ss.getWeight().intValue() < 0) {
            String message = "Incorrect weight for source system [{}]";
            errors.add(new ValidationResult(message, INCORRECT_WEIGHT_FOR_SOURCE_SYSTEM, ss.getName(),
                    ss.getWeight() == null ? null : ss.getWeight().intValue()));
        }

        // 4. Custom properties
        errors.addAll(validateCustomProperties(ss.getName(), ss.getCustomProperties()));
        return errors;
    }

    private SourceSystemsInstance load(String storageId) {

        SourceSystemsPO po = sourceSystemsDAO.current(storageId);
        if (Objects.isNull(po) || ArrayUtils.isEmpty(po.getContent())) {
            return new SourceSystemsInstanceImpl(new SourceSystemsModel()
                    .withVersion(0)
                    .withStorageId(storageId)
                    .withValues(new SourceSystem()
                            .withAdmin(true)
                            .withName("unidata")
                            .withDisplayName("Default admin source system.")
                            .withWeight(100)));
        }

        return new SourceSystemsInstanceImpl(MetaSerializer.sourceSystemsFromCompressedXml(po.getContent()));
    }

    private void processSourceSystems(SourceSystemsInstance i, GetSourceSystemsContext ctx, GetSourceSystemsResult dto) {

        if (!ctx.isAllSourceSystems() && CollectionUtils.isEmpty(ctx.getSourceSystemIds()) && !ctx.isAdminSourceSystem()) {
            return;
        }

        List<GetSourceSystemResult> sourceSystems;
        if (ctx.isAllSourceSystems()) {
            sourceSystems = i.getSourceSystems().stream()
                    .map(el -> ((SourceSystemImpl) el).getSource())
                    .map(GetSourceSystemResult::new)
                    .collect(Collectors.toList());
        } else {
            sourceSystems = ctx.getSourceSystemIds().stream()
                    .map(i::getSourceSystem)
                    .filter(Objects::nonNull)
                    .map(el -> ((SourceSystemImpl) el).getSource())
                    .map(GetSourceSystemResult::new)
                    .collect(Collectors.toList());
        }

        dto.setSourceSystems(sourceSystems);
    }
}
